Skip to content

feat: add Multiple Custom Domain Support#71

Open
kishore7snehil wants to merge 8 commits intomainfrom
feat/multiple-custom-domain
Open

feat: add Multiple Custom Domain Support#71
kishore7snehil wants to merge 8 commits intomainfrom
feat/multiple-custom-domain

Conversation

@kishore7snehil
Copy link
Contributor

@kishore7snehil kishore7snehil commented Feb 23, 2026

📋 Changes

This PR implements Multiple Custom Domain (MCD) support for auth0-api-python, enabling APIs to accept tokens from multiple Auth0 custom domains with static lists, dynamic resolvers, and hybrid mode for zero-downtime domain migrations.

✨ Features

  • Multiple Custom Domain Verification: Accept tokens from multiple Auth0 domains via a domains parameter (static list or callable resolver) on ApiClientOptions
  • Double Issuer Validation: Pre-signature and post-signature issuer checks to prevent issuer confusion attacks
  • Hybrid Mode: Use domain and domains together for migration scenarios — domain drives client-initiated flows (token exchange, connection tokens), domains drives token verification
  • Dynamic Resolver: Runtime domain resolution via DomainsResolver callable with request context (DomainsResolverContext)
  • Per-Issuer Caching: OIDC discovery metadata and JWKS cached per issuer domain with configurable TTL, max entries, and LRU eviction
  • Pluggable Cache Backends: New CacheAdapter ABC allows custom backends (Redis, Memcached, etc.) with a default InMemoryCache implementation

🔧 API Changes

  • Extended ApiClientOptions with MCD parameters: domains, cache_ttl_seconds, cache_max_entries, cache_adapter
  • Added request_url and request_headers parameters to verify_access_token() and verify_request() for resolver context
  • New types: DomainsResolverContext (TypedDict), DomainsResolver (type alias)
  • New error classes: ConfigurationError (invalid SDK config, status 500), DomainsResolverError (resolver failure, status 500)
  • New cache classes: CacheAdapter (ABC), InMemoryCache (default LRU cache with TTL)

📖 Documentation

  • Updated README.md with MCD feature callout and new section 7 (Multi-Custom Domain Support)
  • Created docs/MultipleCustomDomain.md — configuration modes, resolver patterns, error handling, migration guide
  • Created docs/Caching.md — default behavior, custom adapters (Redis example), tuning recommendations

🧪 Testing

  • This change adds test coverage
  • This change has been tested on the latest version of the platform/language
Manual Integration Testing

Requires an Auth0 tenant with multiple custom domains configured and a machine-to-machine application with client credentials grant enabled.

import asyncio
import httpx
from auth0_api_python import ApiClient, ApiClientOptions

DOMAIN = "<AUTH0_DOMAIN>"               # e.g. "dev-tenant.us.auth0.com"
CUSTOM_DOMAIN_1 = "<CUSTOM_DOMAIN_1>"   # e.g. "auth.example.com"
CUSTOM_DOMAIN_2 = "<CUSTOM_DOMAIN_2>"   # e.g. "auth.acme.org"
ALL_DOMAINS = [DOMAIN, CUSTOM_DOMAIN_1, CUSTOM_DOMAIN_2]

CLIENT_ID = "<CLIENT_ID>"
CLIENT_SECRET = "<CLIENT_SECRET>"
AUDIENCE = "<API_AUDIENCE>"


# Helper: get an access token from a specific domain via client_credentials grant.
# POST to https://{domain}/oauth/token with grant_type, client_id, client_secret, audience.
# Returns the access_token string from the response.

async def get_token(domain: str) -> str:
    ...


async def test_bearer_mcd():
    api_client = ApiClient(ApiClientOptions(
        domains=ALL_DOMAINS,
        audience=AUDIENCE,
    ))

    for domain in ALL_DOMAINS:
        token = await get_token(domain)
        claims = await api_client.verify_request(
            headers={"Authorization": f"Bearer {token}"}
        )
        print(f"[PASS] {domain} -> iss={claims['iss']}, sub={claims['sub']}")

asyncio.run(test_bearer_mcd())

Expected: All three domains succeed. Each token's iss matches its issuing domain.

Contributor Checklist

@kishore7snehil kishore7snehil requested a review from a team as a code owner February 23, 2026 13:03
raise VerifyAccessTokenError("Token missing 'iss' claim")

# Normalize issuer for validation
normalized_iss = normalize_domain(unverified_iss)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize_domain here can raise ValueError if iss claim value is malformed.

When that happens, the control hits to the except of this try block. Which will go un-handled.

I think malformed token claims should result also result in VerifyAccessTokenError. It's better to double check what's happening today without MCD in such cases.

raise VerifyAccessTokenError("Discovery metadata missing 'issuer' field")

# Normalize discovery issuer for comparison
normalized_discovery_issuer = normalize_domain(discovery_issuer)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

)

# Normalize domains from resolver
allowed_domains = [normalize_domain(d) for d in result]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as https://github.com/auth0/auth0-api-python/pull/71/changes#r2876890884.

But, here it should throw ConfigurationError.

domain = issuer.replace('https://', '').replace('http://', '').rstrip('/')
else:
domain = self.options.domain
cache_key = normalize_domain(f"https://{domain}")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line in the else block can be hit in the following scenarios.

When the customer hasn't configured domain (Only has domains), the variable cache_key will have the value https://None/.

This is an API as client use-case, and I think we should throw ConfigurationError in such cases.

This is how I am handling this case on auth0-api-js: https://github.com/auth0/auth0-auth-js/pull/120/changes#diff-2333b3f945f6322231360695305103bd95f929eb3754065f3720c1e4746cc7f2R126-R129

Comment on lines +94 to +95
self._discovery_cache = options.cache_adapter
self._jwks_cache = options.cache_adapter

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since _discovery_cache and _jwks_cache use the same cache adapter, should we add a prefix to keys (like discovery: and jwks:) to keep them separate ?

If a custom adapter changes keys (for example removes parts of the URL or uses a simple hash), a discovery key and a JWKS key can become the same key. Then one value can overwrite the other.

self._discovery_cache = options.cache_adapter
self._jwks_cache = options.cache_adapter
else:
self._discovery_cache = InMemoryCache(max_entries=options.cache_max_entries)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we validate cache_max_entries and cache_ttl_seconds at init time (for example cache_max_entries >= 1 and cache_ttl_seconds >= 0) and raise ConfigurationErro on invalid values ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this SDK, we have a provision of devs can bring their Distributed Cache so if they want a real time sync then the condition cache_ttl_seconds >= 0 will cause issues for them.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real time sync can be achieved by setting cache_ttl_seconds to 0 right ?

domain = self.options.domain
cache_key = normalize_domain(f"https://{domain}")

cached = self._discovery_cache.get(cache_key)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add per-key inflight deduplication for discovery/JWKS cache misses , so concurrent requests for the same issuer/JWKS URI don’t trigger duplicate network fetches ?

```

> [!NOTE]
> The resolver runs synchronously. If your lookup requires async I/O (database queries, HTTP calls), wrap it with `asyncio.run()` or pre-load the domain mapping at startup.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolver runs synchronously.
Can you confirm if this is accurate ?


### Host-Header Based

Route allowed domains based on the incoming request's host:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add the same Host/X-Forwarded-Host security note here ?

I am adding it like this in auth0-api-js:
https://github.com/auth0/auth0-auth-js/blob/2f7e16a2339ac9f9a60b5b11d6edd34f3beb5c6e/packages/auth0-api-js/EXAMPLES.md?plain=1#L107-L113

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants